Building a realtime chat app with Django and Fanout Cloud
Chat is one of the most popular uses of realtime data. In this article we’ll explain how to build a web chat app in Django, using Django EventStream and Fanout Cloud. The Django EventStream module makes it easy to push JSON events through Fanout Cloud to connected clients.
Introduction to Django and realtime
Django was created in 2003, when websites were orders of magnitude simpler than they are now. It was built on a request-response framework – the client sends an HTTP request, Django receives it, processes it, and returns a response back to the client.
This framework wasn’t designed for the expectations of the modern web, where it’s common for an app to rely on data moving quickly between many microservices or endpoints. Request-response doesn’t support the persistent, open connection required to send data between endpoints at any time and provide a realtime user experience.
Fanout Cloud
Fanout Cloud gives web services realtime superpowers. Server apps running behind Fanout Cloud can easily and scalably support realtime push mechanisms such as HTTP streaming and WebSockets.
For the chat app in this article, we’ll be using Django EventStream, a convenience module for Django that integrates with Fanout Cloud. Django EventStream makes it easy to push data to clients, using very little code.
What about Django Channels?
Django Channels makes it possible for Django applications to natively handle persistent, open connections for sending realtime updates. We don’t use Django Channels in this chat example, mainly because connection management is being delegated to Fanout Cloud. Having this capability in the backend Django app is simply not needed in this example.
Comparing Django Channels to Fanout Cloud is mostly an apples-to-oranges comparison, though. Django Channels primarily provides asynchronous network programming patterns, bringing Django up to speed with other modern environments like Node.js and Go. Fanout Cloud, on the other hand, is a connection delegation architecture, which is useful for separation of concerns and high scalability. The benefits of Fanout Cloud are orthogonal to whether or not the backend environment supports asynchronous programming.
The chat app
What’s required for a basic chat app? We’ll need:
- A way to load past chat messages when the app is loaded.
- A way to save a chat message on the server.
- A way for new chat messages to be pushed out to clients.
- A way to dynamically manipulate UI elements to display received messages.
When the requirements are broken down like this, you’ll notice that most of these things aren’t necessarily specific to the problem of “realtime”. For example, saving a chat message on the server and loading past messages when the app is opened is pretty conventional stuff.
In fact, we recommend developers start out by building these conventional parts of any new application before worrying about realtime updates. Below we’ll walk through these parts for the chat app.
First, we need to declare some models in models.py
:
from django.db import models
class ChatRoom(models.Model):
eid = models.CharField(max_length=64, unique=True)
class ChatMessage(models.Model):
room = models.ForeignKey(ChatRoom)
user = models.CharField(max_length=64)
date = models.DateTimeField(auto_now=True, db_index=True)
text = models.TextField()
def to_data(self):
out = {}
out['id'] = self.id
out['from'] = self.user
out['date'] = self.date.isoformat()
out['text'] = self.text
return out
Each message object has an associated room object, username, timestamp, and the actual message text. For simplicity, we don’t use real user objects. In a more sophisticated app you’d probably want to include a foreign key to a User
object. The to_data
method converts the message into a plain dictionary, useful for JSON encoding. The room object has a field eid
for storing an external facing ID, so rooms can be referenced using user-supplied IDs/names rather than database row ID.
In urls.py
we declare some endpoints:
from django.conf.urls import url
from . import views
urlpatterns = [
url(r'^$', views.home),
url(r'^(?P<room_id>[^/]+)$', views.home),
url(r'^rooms/(?P<room_id>[^/]+)/messages/$', views.messages),
]
And in views.py
we implement them:
import json
from django.http import HttpResponse, HttpResponseNotAllowed
from django.db import IntegrityError
from django.shortcuts import render, redirect
from .models import ChatRoom, ChatMessage
def home(request, room_id=None):
user = request.GET.get('user')
if user:
if not room_id:
return redirect('/default?' + request.GET.urlencode())
try:
room = ChatRoom.objects.get(eid=room_id)
cmsgs = ChatMessage.objects.filter(
room=room).order_by('-date')[:50]
msgs = []
for msg in reversed(cmsgs):
msgs.append(msg.to_data())
except ChatRoom.DoesNotExist:
msgs = []
context = {}
context['room_id'] = room_id
context['messages'] = msgs
context['user'] = user
return render(request, 'chat/chat.html', context)
else:
context = {}
context['room_id'] = room_id or 'default'
return render(request, 'chat/join.html', context)
def messages(request, room_id):
if request.method == 'POST':
try:
room = ChatRoom.objects.get(eid=room_id)
except ChatRoom.DoesNotExist:
try:
room = ChatRoom(eid=room_id)
room.save()
except IntegrityError:
# someone else made the room. no problem
room = ChatRoom.objects.get(eid=room_id)
mfrom = request.POST['from']
text = request.POST['text']
msg = ChatMessage(room=room, user=mfrom, text=text)
msg.save()
body = json.dumps(msg.to_data())
return HttpResponse(body, content_type='application/json')
else:
return HttpResponseNotAllowed(['POST'])
The home
view loads either the join.html
or chat.html
template depending on whether a username was supplied. The messages
view receives new messages submitted using POST
requests, and the messages are saved in the database.
The chat.html
template is the main chat app. It prepopulates a div
containing past chat messages:
<div id="chat-log">
{% for msg in messages %}
<b>{{ msg.from }}</b>: {{ msg.text }}<br />
{% endfor %}
</div>
And we use jQuery to handle posting new messages:
$('#send-form').submit(function () {
var text = $('#chat-input').val();
$.post('/rooms/{{ room_id }}/messages/', { from: nick, text: text }
).done(function (data) {
console.log('send response: ' + JSON.stringify(data));
}).fail(function () {
alert('failed to send message');
});
return false;
});
This is enough to satisfy the basic, non-realtime needs of the app. The app can be loaded, chat messages can be submitted to the server and saved, and if the app is refreshed it will show the latest messages. We haven’t done anything Fanout-specific yet; this is just normal Django and jQuery code.
Now for the fun part!
Layering on realtime updates
First, we install the Django EventStream module:
pip install django-eventstream
Then we make some changes to settings.py
:
INSTALLED_APPS = [
...
'django_eventstream', # <--- Add module as an app
]
MIDDLEWARE = [
'django_grip.GripMiddleware', # <--- Add middleware as first entry
...
]
# --- Add Fanout Cloud configuration ---
from base64 import b64decode
GRIP_PROXIES = [{
'control_uri': 'http://api.fanout.io/realm/{realm-id}',
'control_iss': '{realm-id}',
'key': b64decode('{realm-key}')
}]
(In your own code, be sure to replace {realm-id}
and {realm-key}
with the values from the Fanout Control Panel.)
Then we add an /events/
endpoint in urls.py
:
from django.conf.urls import include, url
import django_eventstream
urlpatterns = [
...
url(r'^events/', include(django_eventstream.urls)),
]
Alright, the Django EventStream module has been integrated. Now to actually send and receive events.
On the server side, we’ll update the POST
handler to send an event when a chat message is added to the database:
from django.db import transaction
from django_eventstream import send_event
...
mfrom = request.POST['from']
text = request.POST['text']
with transaction.atomic():
msg = ChatMessage(room=room, user=mfrom, text=text)
msg.save()
send_event('room-%s' % room_id, 'message', msg.to_data())
body = json.dumps(msg.to_data())
return HttpResponse(body, content_type='application/json')
Notice that we use a transaction. This way the message won’t be accepted unless an event was also logged.
We’ll also provide the most recent event ID to the template:
from django_eventstream import get_current_event_id
...
last_id = get_current_event_id(['room-%s' % room_id])
try:
room = ChatRoom.objects.get(eid=room_id)
cmsgs = ChatMessage.objects.filter(
room=room).order_by('-date')[:50]
msgs = []
for msg in reversed(cmsgs):
msgs.append(msg.to_data())
except ChatRoom.DoesNotExist:
msgs = []
context = {}
context['room_id'] = room_id
context['last_id'] = last_id
context['messages'] = msgs
context['user'] = user
return render(request, 'chat/chat.html', context)
When the frontend sets up a listening stream, it can indicate the event ID it should start reading after. It’s important that we retrieve the current event ID before retrieving the past chat messages, so that there’s no chance the client misses any messages sent while the page is loading.
Now we can update the frontend to listen for updates and display them. First, include the client libraries:
<script src="{% static 'django_eventstream/json2.js' %}"></script>
<script src="{% static 'django_eventstream/eventsource.min.js' %}"></script>
<script src="{% static 'django_eventstream/reconnecting-eventsource.js' %}"></script>
Then set up a ReconnectingEventSource
object to listen for updates:
var uri = '/events/?channel=room-' + encodeURIComponent('{{ room_id }}');
var es = new ReconnectingEventSource(uri, {
lastEventId: '{{ last_id }}'
});
var firstConnect = true;
es.onopen = function () {
if(!firstConnect) {
appendLog('*** connected');
}
firstConnect = false;
};
es.onerror = function () {
appendLog('*** connection lost, reconnecting...');
};
es.addEventListener('stream-reset', function () {
appendLog('*** client too far behind, please refresh');
}, false);
es.addEventListener('stream-error', function (e) {
// hard stop
es.close();
e = JSON.parse(e.data);
appendLog('*** stream error: ' + e.condition + ': ' + e.text);
}, false);
es.addEventListener('message', function (e) {
console.log('event: ' + e.data);
msg = JSON.parse(e.data);
appendLog('<b>' + msg.from + '</b>: ' + msg.text);
}, false);
A number of callbacks are set up here. The essential ones are the event listeners for message
and stream-error
. The message
event emits whenever a new chat message is received. The stream-error
event emits when the server responds with an unrecoverable error. It’s important to handle this event by stopping the client object, otherwise the client will reconnect and likely get the same error again.
The onopen
and onerror
callbacks, as well as the event listener for stream-reset
, are purely informative, useful to let the user know whether they are connected or not, and or whether the client has been disconnected for too long. You could program the client to automatically reload itself if stream-reset
is received rather than displaying a message to the user.
One other thing: there is a race condition we need to work around, in case a chat message is received twice (once as part of the page, and again as a received event). To solve this, we check incoming message IDs against the IDs of the initial messages provided during page load.
We put the initial message IDs in an array:
var msg_ids = [
{% for msg in messages %}
{% if not forloop.first %},{% endif %}{{ msg.id }}
{% endfor %}
];
Then in our message handler, we check against the array:
es.addEventListener('message', function (e) {
console.log('event: ' + e.data);
msg = JSON.parse(e.data);
// if an event arrives that was already in the initial pageload,
// ignore it
if($.inArray(msg.id, msg_ids) != -1) {
return;
}
appendLog('<b>' + msg.from + '</b>: ' + msg.text);
}, false);
That’s it! The full source for the chat app is here.
Interested in adding realtime updates to your Django app? Get yourself a Fanout Cloud account and start pushing data reliably with Django EventStream today.
Recent posts
-
We've been acquired by Fastly
-
A cloud-native platform for push APIs
-
Vercel and WebSockets
-
Rewriting Pushpin's connection manager in Rust
-
Let's Encrypt for custom domains